"use client"; /** * /w/:workspace/search — full-text search over the workspace (TASK-0098). * * Shares ONE result model with the ⌘K dialog: `searchApi.search` (POST * /search → { data, nextCursor, tookMs }) + `ResultsList`/`ResultRow` * (components/search/results-list.tsx) — the same badges, deep links and * grouped layout. The inline `SearchHit`/envelope normalization from the * Phase-7 draft is gone: the wire shape is the shared `SearchResult`. * * States: loading, empty ("No results for …"), error, and 503 * `SEARCH_UNAVAILABLE` → "Search is temporarily unavailable — try again." * Recency is recorded by the ⌘K dialog; this page keeps the input * shareable via `?q=` (deep link). */ import { Suspense, useEffect, useRef, useState } from "react"; import { useParams, useSearchParams, useRouter } from "next/navigation"; import { ArrowDownWideNarrow, Search, Loader2, ShieldAlert, SearchX, Sparkles } from "lucide-react"; import { searchApi, isApiError } from "@/lib/api"; import { useWorkspace } from "@/hooks/use-workspace"; import { SEARCH_DEBOUNCE_MS, deepLinkFor } from "@/lib/search"; import { ResultsList } from "@/components/search/results-list"; import { AskAiSearch } from "@/components/search/ask-ai-search"; import type { SearchResult } from "@/lib/types"; export default function SearchPage() { // `useSearchParams` needs a Suspense boundary (Next 14 build rule). return (

Loading search…

} >
); } function SearchPageInner() { const params = useParams<{ workspace: string }>(); const workspaceSlug = params?.workspace ?? ""; const { data: workspace } = useWorkspace(workspaceSlug); const router = useRouter(); const searchParams = useSearchParams(); // The shareable query: `?q=` wins on first load, then the input rules. const [input, setInput] = useState(() => searchParams.get("q") ?? ""); const [q, setQ] = useState(() => (searchParams.get("q") ?? "").trim()); // T-155: result ordering (spec §10 "relevance / creation / modification // date"). `relevance` is the API default (the existing `_score` path). const sortParam = searchParams.get("sort"); const [sortBy, setSortBy] = useState(isSortMode(sortParam) ? sortParam : "relevance"); const [hits, setHits] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const reqRef = useRef(0); // The "Ask AI about this workspace" card (TASK-0125 RAG): toggled by the // Ask AI button; the full-page version is /w/:workspace/ask. const [askOpen, setAskOpen] = useState(false); const askRef = useRef(null); // Debounce the input → committed query (same window as the dialog). useEffect(() => { const t = setTimeout(() => setQ(input.trim()), SEARCH_DEBOUNCE_MS); return () => clearTimeout(t); }, [input]); useEffect(() => { if (!q) { setHits(null); return; } const my = (reqRef.current += 1); const controller = new AbortController(); setLoading(true); setError(null); searchApi .search({ q, workspaceId: workspace?.id, limit: 30, sortBy }, controller.signal) .then((res) => { if (my !== reqRef.current) return; setHits(res.data); }) .catch((e: unknown) => { if (my !== reqRef.current) return; if (e instanceof DOMException && e.name === "AbortError") return; setError(toErrorKind(e)); }) .finally(() => { if (my === reqRef.current) setLoading(false); }); return () => controller.abort(); }, [q, sortBy, workspace?.id]); // Keep `?q=` shareable without clobbering in-progress typing. useEffect(() => { const next = new URLSearchParams(searchParams.toString()); if (q) next.set("q", q); else next.delete("q"); // T-155: persist the sort only when it is not the default (clean URLs). if (sortBy !== "relevance") next.set("sort", sortBy); else next.delete("sort"); if (next.toString() !== searchParams.toString()) { router.replace(`/w/${workspaceSlug}/search${next.toString() ? `?${next.toString()}` : ""}`, { scroll: false }); } }, [q, sortBy, searchParams, router, workspaceSlug]); const slug = workspace?.slug ?? workspaceSlug; return (

Search

{/* T-155 — result ordering (spec §10). Changing it re-queries with the * new order; `relevance` is the default and the URL stays clean. */} {/* TASK-0125 — workspace-level RAG entry: opens the "Ask AI about * this workspace" card below (full-page version: /w/:workspace/ask). * TASK-0122's panel entry survives on page/database views. */}
setInput(e.target.value)} aria-label="Search" />
{/* TASK-0125 — the "Ask AI about this workspace" RAG card (the full-page * version is /w/:workspace/ask). Toggled by the Ask AI button; seeded * with the current search query as its starting question. */} {askOpen && workspace ? (
) : null}
{loading && (

Searching…

)} {error !== null && !loading ? ( error === "unavailable" ? (

Search is temporarily unavailable — try again.

Keep typing to retry — your query is preserved.

) : (

Search failed. Check your connection and keep typing to retry.

) ) : null} {!loading && error === null && q && hits && hits.length === 0 && (

No results for “{q}”.

)} {!q && !loading && (

Type to search this workspace — or press ⌘K anywhere.

)} {hits && hits.length > 0 && error === null ? (
{ const link = deepLinkFor(item, slug); if (link) router.push(link); }} />
) : null}
); } /** T-155 — result ordering (mirrors the API's `sortBy` enum). */ type SortMode = "relevance" | "createdAt" | "updatedAt"; const SORT_MODES: readonly SortMode[] = ["relevance", "createdAt", "updatedAt"]; function isSortMode(v: string | null): v is SortMode { return !!v && (SORT_MODES as readonly string[]).includes(v); } type ErrorKind = null | "unavailable" | "generic"; function toErrorKind(e: unknown): ErrorKind { if (isApiError(e)) { if (e.code === "SEARCH_UNAVAILABLE" || e.status === 503) return "unavailable"; return "generic"; } return "generic"; }